Skip to content

feat(x402): support upto scheme + observability + session marker#238

Merged
MQ37 merged 19 commits into
mainfrom
feat/x402-upto
May 26, 2026
Merged

feat(x402): support upto scheme + observability + session marker#238
MQ37 merged 19 commits into
mainfrom
feat/x402-upto

Conversation

@MQ37
Copy link
Copy Markdown
Collaborator

@MQ37 MQ37 commented May 20, 2026

Context

apify-core PR #27039 ships x402 upto scheme support. The prod 402 payment-required response now carries both exact and upto in accepts[], but mcpc's signer only spoke exact (EIP-3009 TransferWithAuthorization).

Server-side counterpart: apify/apify-mcp-server#876.

Solution

  • upto scheme signing — Permit2 PermitWitnessTransferFrom typed-data signing with a one-time USDC.approve(PERMIT2, MAX_UINT256) auto-grant. Skippable with --no-approve.
  • Scheme selectionselectAcceptEntry(accepts, preference) picks from a 402 header by auto / upto / exact. The selector is honored on every code path: mcpc x402 sign --scheme, the proactive _meta.x402 path, and the tool-result 402 retry path.
  • Session-pinned preferencemcpc connect --x402 [auto|upto|exact] persists the scheme to sessions.json and reuses it on mcpc restart. CLI validates against the canonical X402_SCHEME_PREFERENCES const in lib/types.ts; legacy { x402: boolean, x402Scheme } sessions are normalised on read.
  • Observability — verbose logs print scheme=… plus key payment fields up front; USD amounts use 6-decimal (USDC atomic) precision. Sessions using x402 show a yellow [x402] marker in listings.

Worth your attention

  • Permit2 approve is one-time per token, MAX_UINT256 — first upto sign on a new wallet sends a real on-chain tx (a few cents of gas). Subsequent signs are off-chain only; the signer skips the approve call when allowance is already sufficient.
  • Settlement is deferred for upto — apify-core settles 60 min after the last run finishes (or when balance drops to dust / authorization is about to expire). On-chain transfer hash arrives in a follow-up permitWitnessTransferFrom tx, not in the immediate payment-response header (which carries transaction: "").
  • Single user-visible flag--x402-scheme was collapsed into --x402 [scheme] mid-review (adb6a6b). Commander's greedy parser eats the next token as the value; pass --x402=<scheme> when followed by positional args.
  • Canonical scheme typeX402SchemePreference + X402_SCHEME_PREFERENCES live in lib/types.ts. CLI validation, bridge parsing, session normalisation, and middleware all import from there — no string-literal drift.

MQ37 added 4 commits May 20, 2026 14:41
Adds the `upto` scheme alongside the existing `exact` flow:

- `signUptoPayment` — EIP-712 typed-data signing over Permit2's
  PermitWitnessTransferFrom struct (witness binds {to, facilitator,
  validAfter}), with decimal-string uint256 nonce.
- One-time Permit2 ERC-20 allowance auto-approval — checks
  `USDC.allowance(wallet, PERMIT2)` and submits
  `USDC.approve(PERMIT2, MAX_UINT256)` if short. Bypassable with
  `--no-approve` for advanced/testing flows.
- `selectAcceptEntry(accepts, preference)` — picks a valid accept
  from the 402 `accepts[]` array. `auto` prefers `upto`, falls back to
  `exact`; explicit `upto` or `exact` forces one.
- `parsePaymentRequired` and `extractAcceptFromPaymentRequired`
  now use the selector instead of hard-coding `exact`.
- `mcpc x402 sign` gains `--scheme <auto|upto|exact>` and
  `--no-approve` flags.
- 30 Vitest unit tests cover the new code paths.

Spec: https://github.com/coinbase/x402/blob/main/specs/schemes/upto/scheme_upto_evm.md

End-to-end validated against api.apify.com on Base Mainnet:
- HTTP 402 returns both schemes in accepts[]
- mcpc x402 sign --scheme upto produces a wire-correct payload
- POST with PAYMENT-SIGNATURE returns 201 + payment-response (success: true,
  transaction: '' — settlement deferred to the apify-core daemon per spec)

Known follow-up (not blocking review):
- `--scheme` preference not yet plumbed into the session-level `--x402`
  flow (only the manual `x402 sign` command honors it today).

Investigation doc in X402_UPTO_INVESTIGATION.md captures the on-chain proof,
the original CDP /verify schema gap (resolved upstream by now — prod
verifies upto fine), and the debugging history.
When verbose mode is on, the x402 signer now announces the scheme +
key payment fields up front:

  [x402-signer] Signing x402 payment: scheme=upto network=eip155:8453
    amount=1000000 asset=0x... payTo=0x... facilitator=0x...

The two existing 'payment signed' summaries in the fetch middleware now
include `scheme=` and the bridge retry log uses the same precision —
all USD amounts in debug logs are now 6 decimals (USDC atomic precision)
instead of 4.
Sessions with auto-payment enabled show a yellow [x402] marker in
`mcpc` listings, matching the visual style of the OAuth and proxy
markers. OAuth and x402 are mutually exclusive auth mechanisms, so
the marker replaces the (OAuth: ...) one when both happen to be set
on the session record.
`mcpc connect --x402-scheme <auto|upto|exact>` plumbs the scheme
preference end-to-end:

- CLI validates against `X402_SCHEME_PREFERENCES` (canonical source in
  `lib/types.ts`) and rejects `--x402-scheme` without `--x402`.
- Persisted on `SessionData.x402Scheme` so `mcpc restart` reuses the
  choice.
- Forwarded to the bridge as `--x402-scheme <value>` and passed to
  `createX402FetchMiddleware({ schemePreference })`, which already
  honored the option.

Default (when not specified) is `auto` — prefer upto, fall back to
exact — same as the existing `mcpc x402 sign` default.
@TateLyman
Copy link
Copy Markdown

Ran a focused pass on the --x402-scheme plumbing because this PR introduces auto|upto|exact as a user-visible safety lever.

One launch-risk edge I would fix before merge:

--x402-scheme exact is only honored on the HTTP 402 fallback path. The proactive tool metadata path still signs whatever flat _meta.x402 advertises, and the MCP tool-result retry path still selects with hard-coded auto.

Relevant paths:

  • createX402FetchMiddleware() receives schemePreference, but calls getOrSignPayment(init, wallet, getToolByName, paymentCache) without passing it.
  • getOrSignPayment() then builds an accept directly from the flat tool _meta.x402 fields. The companion server PR intentionally prefers upto for those flat fields, so an mcpc connect --x402-scheme exact ... session can still sign upto proactively before it ever reaches the fallback that respects exact.
  • extractAcceptFromPaymentRequired() also calls selectAcceptEntry(..., 'auto'), and BridgeProcess.handlePaymentRequiredRetry() uses that helper without passing this.options.x402Scheme.

Why it matters: upto has the one-time Permit2 approval path and different settlement timing. If a user pins exact specifically to avoid Permit2 approval/deferred settlement, the first paid tools/call can still take the upto branch as long as the server advertises flat _meta.x402 from the preferred accept. That turns the scheme preference into a fallback-only hint instead of a hard session policy.

Suggested patch shape:

  • pass schemePreference into getOrSignPayment();
  • when tool metadata includes accepts[], call selectAcceptEntry(accepts, schemePreference ?? 'auto') instead of trusting only the flat fields;
  • make extractAcceptFromPaymentRequired(data, schemePreference = 'auto') accept the preference and use it from handlePaymentRequiredRetry();
  • add a regression test where metadata has both upto and exact, schemePreference: 'exact', and the signed payload ends up accepted.scheme === 'exact' with no Permit2 allowance check.

Scope: code review only. I did not run a paid Apify call, send payment headers, sign wallet payloads, or attempt settlement.

…etry paths

`--x402-scheme` previously only kicked in on the HTTP-402 fallback. Two other
signing paths defaulted to `auto` and signed whatever the server preferred:

1. The proactive-sign path (`getOrSignPayment`) read only the flat
   `_meta.x402.{scheme,…}` fields, missing the new `accepts[]` advertising
   and ignoring `schemePreference` entirely.
2. The tool-result retry path (`extractAcceptFromPaymentRequired` called from
   `BridgeProcess.handlePaymentRequiredRetry`) hard-coded `selectAcceptEntry(..., 'auto')`.

Both now honor the configured preference end-to-end:

- `createX402FetchMiddleware` passes `schemePreference` into `getOrSignPayment`.
- New `selectAcceptFromToolMeta` helper consumes `_meta.x402.accepts[]` when
  present (post apify-mcp-server #876), falling back to flat fields only when
  the preference matches the flat scheme (or preference is `auto`).
- `extractAcceptFromPaymentRequired` takes a `schemePreference` parameter and
  the bridge passes `this.options.x402Scheme` through to it.

When the proactive path can't honor the preference (e.g. pre-#876 server with
flat-only `_meta.x402.scheme=upto` and `--x402-scheme exact`), it skips signing
and lets the 402 fallback handle it \u2014 the 402 response is the authoritative
source of `accepts[]` regardless of what the server advertises proactively.

Refs #238 review comment from @TateLyman.

New `fetch-middleware.test.ts` covers:
- proactive sign with accepts=[exact, upto] and schemePreference=exact \u2192 signs exact
- proactive sign with accepts=[exact, upto] and schemePreference=upto \u2192 signs upto
- proactive sign with accepts=[upto] and schemePreference=exact \u2192 skips
- proactive sign with legacy flat-only upto and schemePreference=exact \u2192 skips
- proactive sign with default auto preference \u2192 prefers upto
- extractAcceptFromPaymentRequired with each preference value
@MQ37
Copy link
Copy Markdown
Collaborator Author

MQ37 commented May 21, 2026

Good catch — fixed in e374380.

The schemePreference was indeed leaky to the HTTP-402 fallback only. Now plumbed end-to-end:

  • createX402FetchMiddleware passes schemePreference into getOrSignPayment.
  • New selectAcceptFromToolMeta helper consumes _meta.x402.accepts[] when present, falling back to flat fields only when the preference matches the flat scheme (or preference is auto).
  • extractAcceptFromPaymentRequired takes schemePreference and the bridge passes this.options.x402Scheme through to it.

When the proactive path can't honor the preference (e.g. pre-#876 server with flat-only _meta.x402.scheme=upto and --x402-scheme exact), it skips signing and defers to the 402 fallback — the 402 response is the authoritative source regardless of proactive advertising.

New fetch-middleware.test.ts adds the regression coverage you suggested:

  • proactive sign with accepts=[exact, upto] and schemePreference=exact → signs exact (no Permit2 calls)
  • proactive sign with accepts=[upto] only and schemePreference=exact → skips, defers to 402
  • legacy flat-only upto advertising with schemePreference=exact → skips, defers to 402
  • extractAcceptFromPaymentRequired honors each 'auto' | 'upto' | 'exact' value

…ADME

- Explains `exact` (EIP-3009) vs `upto` (Permit2) scheme semantics.
- Documents `--x402-scheme <auto|upto|exact>` session configuration and persistence.
- Adds `--scheme` and `--no-approve` options to `mcpc x402 sign` table.
- Cleans up type duplication of `SchemePreference` by aliasing `X402SchemePreference` imported from types.ts.
Comment thread src/bridge/index.ts Outdated
When the bridge process crashes in the background, `restartBridge` reads the saved
session record from `sessions.json` and spawns a new bridge. Previously, it only
forwarded `session.x402: true` to `bridgeOptions` but forgot to plumb
`session.x402Scheme` (the pinned preference). This caused the restarted bridge
to revert to the default `auto` scheme preference, violating the pinned session policy.

Now correctly forwards `session.x402Scheme` on automatic crash restarts.

Refs Rule 25: Plumb user-visible configurations end-to-end to avoid leaky parameter gaps.
Copy link
Copy Markdown
Member

@jancurn jancurn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure that if one uses mcpc connect --x402-schema xxx we implicitly consider as if they added --x402 too, to make it less error prone

MQ37 added 3 commits May 21, 2026 11:30
…p dead conditional in upto signer

Two small leaky-parameter findings from a second-pass review (Rule 25 in code-quality skill):

1. `BulkConnectOptions` did not declare `x402Scheme`. The value was passed
   implicitly via `{ ...globalOpts }` spread to `connectAllFromConfig` /
   `connectAllFromStandardConfigs`, so it survived at runtime, but the typed
   parameter view dropped it \u2014 fragile to any future refactor that destructures
   the options object instead of re-spreading it. Declare the field and add
   explicit spreads at the two CLI call sites so the contract is type-enforced.

2. `signUptoPayment` built the accepted.extra block with
   `...(facilitatorAddress ? { facilitatorAddress } : {})`, but the function
   throws earlier when `facilitatorAddress` is empty \u2014 the false branch was
   unreachable. Inline the field directly with a one-line invariant comment.
Single user-visible flag instead of two. Boolean+enum data model becomes
one nullable string field. Eliminates the 'scheme set without x402: true'
class of bugs and shrinks Rule 25 surface area in half.

CLI:

- `--x402 [scheme]` (optional value): bare `--x402` defaults to 'auto';
  `--x402 upto` / `--x402 exact` pins the preference.
- `--x402-scheme` removed.
- Commander's greedy parser eats the next token as the value; CLI validates
  it must be in {auto,upto,exact} and throws otherwise so a URL/session can't
  slip through silently. Help text documents `--x402=<scheme>` as the
  unambiguous form when followed by positional args.

Data model:

- `SessionData.x402: X402SchemePreference | undefined` (presence = enabled).
- Legacy fields `x402: boolean` + `x402Scheme` are normalised on session read
  by `normaliseLegacyX402` and rewritten on the next save. Read once, then
  the on-disk format converges to the new shape.

Plumbing tightened (one parameter instead of two):

- `BridgeOptions.x402`, `StartBridgeOptions.x402`, `HandlerOptions.x402`,
  `BulkConnectOptions.x402` all become `X402SchemePreference?`.
- Bridge IPC arg is now `--x402 <scheme>` (was `--x402` + `--x402-scheme`).
- `createX402FetchMiddleware` and `extractAcceptFromPaymentRequired` receive
  the value directly from `this.options.x402` \u2014 no second field to keep in sync.

Tests:

- 7 new unit tests for `normaliseLegacyX402` covering legacy true/false,
  legacy true+scheme, idempotency, defensive drop on invalid strings, stale
  `x402Scheme` sidecar without parent flag.
- Stub-resistant per Rule 22: 5 of 7 fail when the migrator is no-op'd.
- Full suite: 627 tests pass (+7 from previous 620).

Docs:

- README auth-flags table updated; 'Using x402 with MCP servers' subsection
  rewritten to show the new examples and document the equals form.
- CHANGELOG Unreleased entry rewritten.

Breaking: `--x402-scheme` was added in this same Unreleased cycle and never
shipped \u2014 no released-API users to migrate. The on-disk legacy shape is
auto-migrated transparently on read.
…sage

The four validation throws in `getOptionsFromCommand` (`--timeout`, `--x402`,
`--schema-mode`, `--max-chars`) used plain `new Error(...)`, which bubbled up
as an uncaught exception and dumped a full Node stack trace on stderr.

Swap them to `ClientError` so the top-level handler formats them as a
one-line "Error: ..." message and exits with code 1, matching every other
user-visible validation error in the codebase.
@MQ37
Copy link
Copy Markdown
Collaborator Author

MQ37 commented May 21, 2026

@jancurn I unified the CLI args and right now there is only the --x402 [auto/upto/exact] with optional schema option - by default it uses auto that prefers the upto

MQ37 added 5 commits May 25, 2026 10:54
The standalone example was orphaned — not referenced from README, docs,
tests, or any code. Users should use `mcpc x402 sign` or the official
x402 SDK instead.
`3600` was inlined at 3 call-sites (exact signer, upto signer, proactive
`_meta.x402` fallback). Replace with a single named export from
`signer.ts`.
Comment thread src/cli/index.ts

// Commander returns `true` for `--x402` (no value) and a string for `--x402 <scheme>`.
// Normalise to the canonical scheme preference; reject other strings loudly so
// commander's greedy [optional] arg parser can't silently eat a positional like a URL.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add an e2e test to ensure mcpc connect --x402 mpc.apify.com works well?

Comment thread src/lib/sessions.ts Outdated
Copy link
Copy Markdown
Member

@jancurn jancurn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some minor comments. BTW any way to add e2e tests for this?

- normaliseLegacyX402: drop the x402Scheme sidecar branch. That two-field
  shape only existed mid-feature-branch (0d65e96..adb6a6b); no released
  version ever wrote it to disk. Keep only the v0.3.0 `{x402: boolean}`
  → `auto` migration and bogus-value defense.
- Bridge --x402 parser: throw on missing/invalid value instead of silently
  falling back to 'auto'. CLI already validates strictly; the tolerant
  branch was dead code that would hide bugs from direct bridge invocation.
- README upto section: compress the 3-bullet + 4-step explanation into
  one paragraph. Same information, half the lines.
@MQ37
Copy link
Copy Markdown
Collaborator Author

MQ37 commented May 25, 2026

@jancurn still finalizing - I noticed there was some POC junk.

For e2e tests we would need to have some wallet - I will create an issue for that. Or we can use the agentic payments e2e Actor that is running daily and we can use latest version of mcpc there directly.

@jancurn
Copy link
Copy Markdown
Member

jancurn commented May 25, 2026

Cheers for info. Is there no way to mock these tests somehow?

Commander's `[optional]` arg parser greedily consumes the next token as
the option value, so `mcpc connect --x402 mcp.apify.com @s` would parse
the URL as the scheme. Pre-process argv in `main()`: when `--x402` is
followed by a token that's not in {auto, upto, exact}, rewrite to
`--x402=auto` so the next token stays a positional. Tokens that are
valid schemes pass through unchanged.

Validated end-to-end against mcp.apify.com: `mcpc connect --x402
'https://mcp.apify.com?payment=x402&actors=apify/instagram-scraper' @s`
now succeeds and signs an upto-scheme payment to the facilitator.

Drops the now-redundant `--x402=<scheme>` workaround note from
`connect` help, README, and CHANGELOG. Also removes the dead
`x402?: boolean` field from `extractOptions()` (unread since the
`--x402-scheme` collapse in adb6a6b).
@MQ37
Copy link
Copy Markdown
Collaborator Author

MQ37 commented May 25, 2026

@jancurn we can mock them but that would not be e2e and I think that ultimately loses the point.

I will create issue for that and we can iterate on this and use real wallet for that, but the issue is that it locks the 1 USDC for each run for an hour and that might lead to failures of CICD sometimes - so the wallet would need to be properly funded with a buffer for this scenario.

MQ37 added 2 commits May 25, 2026 15:40
x402 is marked experimental in `mcpc x402 init` output; breaking the
on-disk session shape between releases is acceptable. v0.3.0 users
with persisted `{x402: true}` sessions will see the bridge fail loudly
with "--x402 requires a scheme" on next restart \u2014 the fix is to
re-run `mcpc connect --x402 <url> @session`.

Removes the function, its call-site in `loadSessionsInternal`, the
dedicated test file (5 tests), and the legacy-shape paragraph from the
`SessionData.x402` JSDoc.
@MQ37 MQ37 merged commit 597a344 into main May 26, 2026
6 checks passed
@MQ37 MQ37 deleted the feat/x402-upto branch May 26, 2026 09:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants